Skip to content
This repository has been archived by the owner on Apr 14, 2023. It is now read-only.

Fixes the DedupLink shared observable unsubscribing early when shared #984

Merged
merged 2 commits into from
Mar 13, 2019
Merged

Fixes the DedupLink shared observable unsubscribing early when shared #984

merged 2 commits into from
Mar 13, 2019

Conversation

ms
Copy link
Contributor

@ms ms commented Mar 13, 2019

Context

The DedupLink creates a real (single) observable to listen to when a
request is made and returns a shared observable to its caller. When a
second identical request is made, the real observable is left untouched
but the subscriber's callback functions is added to the list of
callbacks to be triggered when the request resolves and the shared
observable is returned.

Problem

Unfortunately the shared observable returns cleanup logic that
unsubscribes from the real observable without checking whether only one
of many (shared) subscribers is unsubscribing. In the case where
DedupLink is in front of HttpLink, this leads to HttpLink aborting the
HTTP request while some callers are still waiting for a response.

Fix

This change modifies the sharing code to use a Set of observers and only
unsubscribe from the underlying Observable when the last observer is
removed.

TODO:

  • Make sure all of new logic is covered by tests and passes linting
  • Update CHANGELOG.md with your change

@apollo-cla
Copy link

@ms: Thank you for submitting a pull request! Before we can merge it, you'll need to sign the Meteor Contributor Agreement here: https://contribute.meteor.com/

The DedupLink creates a real (single) observable to listen to when a
request is made and returns a shared observable to its caller. When a
second identical request is made, the real observable is left untouched
but the subscriber's callback functions is added to the list of
callbacks to be triggered when the request resolves and the shared
observable is returned.

Unfortunately the shared observable returns cleanup logic that
unsubscribes from the real observable without checking whether only one
of many (shared) subscribers is unsubscribing. In the case where
DedupLink is in front of HttpLink, this leads to HttpLink aborting the
HTTP request while some callers are still waiting for a response.

This change modifies the sharing code to use a Set of observers and only
unsubscribe from the underlying Observable when the last observer is
removed.
@codecov-io
Copy link

codecov-io commented Mar 13, 2019

Codecov Report

Merging #984 into master will decrease coverage by <.01%.
The diff coverage is 100%.

Impacted file tree graph

@@            Coverage Diff             @@
##           master     #984      +/-   ##
==========================================
- Coverage    95.2%   95.19%   -0.01%     
==========================================
  Files          22       22              
  Lines        1022     1020       -2     
  Branches      104       92      -12     
==========================================
- Hits          973      971       -2     
  Misses         44       44              
  Partials        5        5
Impacted Files Coverage Δ
packages/apollo-link-dedup/src/dedupLink.ts 100% <100%> (ø) ⬆️

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 35b9aa2...747426a. Read the comment docs.

@ms
Copy link
Contributor Author

ms commented Mar 13, 2019

Just to add a bit more context around this: we were running into issues where requests were being made successfully in cache-first mode but failing when we switch to network-only. After a bit of debugging we realized that were were mounting, updating and unmounting the nested Query component multiple times (because of up-tree components resolving and updating their state, including an other Query) and that we happened to be sending the exact same query object and deep equal but non-identical variables object.

The result was that every query observable ended up with two subscribers: the one created in the Query component itself as well as the one created by ObservableQuery.result() after Query.updateQuery. Because the latter creates a Promise that unsubscribes on the next tick, we ran into race conditions where the Query component was unmounted and remounted between the original resolution and the unsubscribe(). When the second creation of the same <Query /> triggered its fetch query, it was deduped by the DedupLink in the process (since the last observer hadn't unsubscribed yet) which was then aborted immediately thereafter when the setTimeout triggered.

A related question I have is whether it makes sense to use a Promise in result() in https://github.com/apollographql/apollo-client/blob/master/packages/apollo-client/src/core/ObservableQuery.ts#L121-L153 since the underlying Observable actually calls next multiple times (in loading: true and loading: false states, for example). It's possible I'm misunderstanding this code though

@hwillson hwillson self-assigned this Mar 13, 2019
Copy link
Member

@hwillson hwillson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is great @ms - thanks very much!

@hwillson hwillson merged commit a4f1520 into apollographql:master Mar 13, 2019
benjamn added a commit to apollographql/apollo-client that referenced this pull request Mar 14, 2019
Since PR #4576 removes the dependency on apollo-link-dedup by replacing it
with an internal implementation, this recent apollo-link-dedup PR caught
my attention: apollographql/apollo-link#984

After adapting the regression test from that PR, it became clear that
multiplexing should be the default behavior for all link Observables. In
other words, the underlying observable.subscribe method should be called
at most once, yet the results/errors should be delivered to all observers
without actually subscribing each observer to the underlying observable.
The single shared subscription gets unsubscribed once all observers have
unsubscribed.

When I tried to adopt this policy previously (since it is required for
subscriptions, and seemed like a good idea for queries and mutations too),
a single test failed ('returns the same value as observableQuery.next
got'), but with some additional effort this time I was able to track that
down to observable.refetch() accidentally reusing an old (deduplicated)
observable because the new deduplication logic wasn't calling the cleanup
function after the first next event on the underlying observable. Once I
fixed that, all tests passed.
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants